Long only 1/n portfolio#

import pandas as pd
pd.options.plotting.backend = "plotly"

import yfinance as yf

from cvx.simulator.builder import builder
from cvx.simulator.grid import resample_index
data = yf.download(tickers = "SPY AAPL GOOG MSFT",  # list of tickers
                   period = "10y",                   # time period
                   interval = "1d",                 # trading interval
                   prepost = False,                 # download pre/post market hours data?
                   repair = True)                   # repair obvious price errors e.g. 100x?
[                       0%                       ]
[**********************50%                       ]  2 of 4 completed
[**********************75%***********            ]  3 of 4 completed
[*********************100%***********************]  4 of 4 completed
1 Failed download:
['SPY']: Exception("The following 'Dividends' events are out-of-range, did not expect with interval 1d: DatetimeIndex(['2013-06-21 00:00:00-04:00', '2013-09-20 00:00:00-04:00',\n               '2013-12-20 00:00:00-05:00', '2014-03-21 00:00:00-04:00',\n               '2014-06-20 00:00:00-04:00', '2014-09-19 00:00:00-04:00',\n               '2014-12-19 00:00:00-05:00', '2015-03-20 00:00:00-04:00',\n               '2015-06-19 00:00:00-04:00', '2015-09-18 00:00:00-04:00',\n               '2015-12-18 00:00:00-05:00', '2016-03-18 00:00:00-04:00',\n               '2016-06-17 00:00:00-04:00', '2016-09-16 00:00:00-04:00',\n               '2016-12-16 00:00:00-05:00', '2017-03-17 00:00:00-04:00',\n               '2017-06-16 00:00:00-04:00', '2017-09-15 00:00:00-04:00',\n               '2017-12-15 00:00:00-05:00', '2018-03-16 00:00:00-04:00',\n               '2018-06-15 00:00:00-04:00', '2018-09-21 00:00:00-04:00',\n               '2018-12-21 00:00:00-05:00', '2019-03-15 00:00:00-04:00',\n               '2019-06-21 00:00:00-04:00', '2019-09-20 00:00:00-04:00',\n               '2019-12-20 00:00:00-05:00', '2020-03-20 00:00:00-04:00',\n               '2020-06-19 00:00:00-04:00', '2020-09-18 00:00:00-04:00',\n               '2020-12-18 00:00:00-05:00', '2021-03-19 00:00:00-04:00',\n               '2021-06-18 00:00:00-04:00', '2021-09-17 00:00:00-04:00',\n               '2021-12-17 00:00:00-05:00', '2022-03-18 00:00:00-04:00',\n               '2022-06-17 00:00:00-04:00', '2022-09-16 00:00:00-04:00',\n               '2022-12-16 00:00:00-05:00', '2023-03-17 00:00:00-04:00',\n               '2023-06-16 00:00:00-04:00'],\n              dtype='datetime64[ns, America/New_York]', freq=None)")

prices = data["Adj Close"]
capital = 1e6
b = builder(prices=prices, initial_cash=capital)

for time, state in b:
    # each day we invest a quarter of the capital in the assets
    b[time[-1]] = 0.25 * state.nav / state.prices
portfolio = b.build()
portfolio.profit.cumsum().plot()
portfolio.nav.plot()

Rebalancing#

Usually we would not execute on a daily basis but rather rebalance every week, month or quarter. There are two approaches to deal with this problem in cvxsimulator.

  • Resample the existing daily portfolio (helpful to see effect of your hesitated trading)

  • Trade only on days that are within a predefined grid (most flexible if you have a rather irregular grid)

Resample an existing portfolio#

portfolio_resampled = portfolio.resample(rule="M")
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[8], line 1
----> 1 portfolio_resampled = portfolio.resample(rule="M")

File ~/work/cvxmarkowitz/cvxmarkowitz/.venv/lib/python3.10/site-packages/cvx/simulator/portfolio.py:430, in EquityPortfolio.resample(self, rule)
    420 """The resample method resamples an EquityPortfolio object to a new frequency
    421 specified by the rule argument.
    422 A new EquityPortfolio object is created with the original prices
   (...)
    427 but rather returns a new object.
    428 """
    429 # iron out the stocks index
--> 430 stocks = iron_frame(self.stocks, rule=rule)
    432 return EquityPortfolio(
    433     prices=self.prices,
    434     stocks=stocks,
    435     trading_cost_model=self.trading_cost_model,
    436     initial_cash=self.initial_cash,
    437 )

File ~/work/cvxmarkowitz/cvxmarkowitz/.venv/lib/python3.10/site-packages/cvx/simulator/grid.py:17, in iron_frame(frame, rule)
      8 def iron_frame(frame, rule):
      9     """
     10     The iron_frame function takes a pandas DataFrame
     11     and keeps it constant on a coarser grid.
   (...)
     15     :return: the ironed frame
     16     """
---> 17     s_index = resample_index(frame.index, rule)
     18     return _project_frame_to_grid(frame, s_index)

File ~/work/cvxmarkowitz/cvxmarkowitz/.venv/lib/python3.10/site-packages/cvx/simulator/grid.py:31, in resample_index(index, rule)
     22 """
     23 The resample_index function resamples a pandas DatetimeIndex object
     24 to a lower frequency using a specified rule.
   (...)
     28 but rather returns a pandas DatetimeIndex
     29 """
     30 series = pd.Series(index=index, data=index)
---> 31 a = series.resample(rule=rule).first()
     32 return pd.DatetimeIndex(a.values)

File ~/work/cvxmarkowitz/cvxmarkowitz/.venv/lib/python3.10/site-packages/pandas/core/series.py:5719, in Series.resample(self, rule, axis, closed, label, convention, kind, on, level, origin, offset, group_keys)
   5704 @doc(NDFrame.resample, **_shared_doc_kwargs)  # type: ignore[has-type]
   5705 def resample(
   5706     self,
   (...)
   5717     group_keys: bool = False,
   5718 ) -> Resampler:
-> 5719     return super().resample(
   5720         rule=rule,
   5721         axis=axis,
   5722         closed=closed,
   5723         label=label,
   5724         convention=convention,
   5725         kind=kind,
   5726         on=on,
   5727         level=level,
   5728         origin=origin,
   5729         offset=offset,
   5730         group_keys=group_keys,
   5731     )

File ~/work/cvxmarkowitz/cvxmarkowitz/.venv/lib/python3.10/site-packages/pandas/core/generic.py:8888, in NDFrame.resample(self, rule, axis, closed, label, convention, kind, on, level, origin, offset, group_keys)
   8885 from pandas.core.resample import get_resampler
   8887 axis = self._get_axis_number(axis)
-> 8888 return get_resampler(
   8889     cast("Series | DataFrame", self),
   8890     freq=rule,
   8891     label=label,
   8892     closed=closed,
   8893     axis=axis,
   8894     kind=kind,
   8895     convention=convention,
   8896     key=on,
   8897     level=level,
   8898     origin=origin,
   8899     offset=offset,
   8900     group_keys=group_keys,
   8901 )

File ~/work/cvxmarkowitz/cvxmarkowitz/.venv/lib/python3.10/site-packages/pandas/core/resample.py:1523, in get_resampler(obj, kind, **kwds)
   1519 """
   1520 Create a TimeGrouper and return our resampler.
   1521 """
   1522 tg = TimeGrouper(**kwds)
-> 1523 return tg._get_resampler(obj, kind=kind)

File ~/work/cvxmarkowitz/cvxmarkowitz/.venv/lib/python3.10/site-packages/pandas/core/resample.py:1713, in TimeGrouper._get_resampler(self, obj, kind)
   1704 elif isinstance(ax, TimedeltaIndex):
   1705     return TimedeltaIndexResampler(
   1706         obj,
   1707         timegrouper=self,
   (...)
   1710         gpr_index=ax,
   1711     )
-> 1713 raise TypeError(
   1714     "Only valid with DatetimeIndex, "
   1715     "TimedeltaIndex or PeriodIndex, "
   1716     f"but got an instance of '{type(ax).__name__}'"
   1717 )

TypeError: Only valid with DatetimeIndex, TimedeltaIndex or PeriodIndex, but got an instance of 'Index'
frame = pd.DataFrame({"original": portfolio.nav, "monthly": portfolio_resampled.nav})
frame
original monthly
Date
2013-05-30 1.000000e+06 1.000000e+06
2013-05-31 9.945921e+05 9.945921e+05
2013-06-03 1.000400e+06 1.000391e+06
2013-06-04 9.917402e+05 9.917309e+05
2013-06-05 9.846364e+05 9.846127e+05
... ... ...
2023-05-23 7.505451e+06 7.493060e+06
2023-05-24 7.461416e+06 7.447306e+06
2023-05-25 7.603349e+06 7.590982e+06
2023-05-26 7.711937e+06 7.698989e+06
2023-05-30 7.735463e+06 7.721169e+06

2518 rows × 2 columns

print(portfolio_resampled.stocks)
                    AAPL          GOOG         MSFT          SPY
Date                                                            
2013-05-30  17854.390520  11527.266005  8564.268967  1814.033248
2013-05-31  17854.390520  11527.266005  8564.268967  1814.033248
2013-06-03  17895.614489  11573.477982  8432.882983  1831.100869
2013-06-04  17895.614489  11573.477982  8432.882983  1831.100869
2013-06-05  17895.614489  11573.477982  8432.882983  1831.100869
...                  ...           ...          ...          ...
2023-05-23  10590.586692  16651.905094  5882.633509  4316.566726
2023-05-24  10590.586692  16651.905094  5882.633509  4316.566726
2023-05-25  10590.586692  16651.905094  5882.633509  4316.566726
2023-05-26  10590.586692  16651.905094  5882.633509  4316.566726
2023-05-30  10590.586692  16651.905094  5882.633509  4316.566726

[2518 rows x 4 columns]
# almost hard to see that difference between the original and resampled portfolio
frame.plot()
# number of shares traded
portfolio_resampled.trades_stocks.iloc[1:].plot()

Trade only days in predefined grid#

b = builder(prices=prices, initial_cash=capital)

# define a grid
grid = resample_index(prices.index, rule="M")

for time, state in b:
    # each day we invest a quarter of the capital in the assets
    if time[-1] in grid:
        b[time[-1]] = 0.25 * state.nav / state.prices
    else:
        # forward fill an existing position
        b[time[-1]] = b[time[-2]]
        
portfolio = b.build()
portfolio.nav.plot()
# Trading only once a month can lead to days where 150k had to be reallocated
portfolio.turnover.iloc[1:].plot()

Why not resampling the prices?#

I don’t believe in bringing the prices to a monthly grid. This would render it hard to construct signals given the sparse grid. We stay on a daily grid and trade once a month.